Skip to content

fix: show pending questions reliably and open the right chat from notifications - #235

Merged
yuga-hashimoto merged 1 commit into
mainfrom
fix/question-render-and-notification
Aug 10, 2026
Merged

fix: show pending questions reliably and open the right chat from notifications#235
yuga-hashimoto merged 1 commit into
mainfrom
fix/question-render-and-notification

Conversation

@yuga-hashimoto

@yuga-hashimoto yuga-hashimoto commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes two reported bugs:

  1. The question tool sometimes does not render — especially when the question was asked while the user was in another session, or when a long time passed before they looked at it.
  2. Tapping a notification does not open the target session — the app opens but stays on (or shows) the wrong chat.

Root causes and fixes

Question rendering

Cause Fix
Recovery fetched GET /question?directory=<selected workspace>, but the composer's selected workspace is rarely the session's own directory, so the query found nothing Resolve the directory from the session being opened (backend.session(id).directory), keep it in ChatUiState.sessionDirectory as the answer/reject fallback, and retry transient fetch failures
Claude Code had no recovery path (pendingQuestions returned emptyList()); the one-shot bridge event was the only delivery ClaudePermissionBridge.pendingRequests() reads pending files without consuming the watcher's one-shot emission; ClaudeCodeRuntime.pendingQuestions() exposes only questions whose session process is alive (a file left by a dead process can never be answered); ClaudeCodeTarget overrides pendingQuestions
Claude Code's decline button threw "unsupported" ClaudeCodeTarget.rejectQuestion writes the bridge's deny response, failing the waiting tool call cleanly
The guest hook expired questions after ~75 s: the loop counted iterations of a 0.25 s sleep as seconds (TIMEOUT_SEC=300 iterations) Deadline-based real-second timing; permissions keep 5 min, questions wait up to the Claude hook timeout, raised 360 s → 3600 s
Dismissed cards were resurrected by every refetch (e.g. stream reconnect) Dismissals are remembered for the current visit to the session; reopening the chat offers the question again
Question notifications lingered after the answer, leading to sessions with nothing to show Answering/declining cancels the notification via a new onQuestionResolved callback

Notification tap → session

Cause Fix
The deep link was a bare session id, never consumed; a second tap for the same session changed no state, so the LaunchedEffect never re-ran One-shot tokenised ChatDeepLink; intent extras are consumed so recreation can't re-navigate
Question/complete/error notifications carried no runtime id; chats on another agent opened against the wrong backend Every notification now carries runtime_id; the deep link switches the runtime first (falling back to the session catalog for notifications posted before this change) and resolves the real chat title
Cold start with no runtime selected: openSession silently no-opped and the pending request was dropped anyway The pending-session effect keeps the request until a runtime exists (also keyed on the runtime so a switch re-runs it against the right view model)
PendingIntent/notification-id collisions (requestCode = sessionId.hashCode(), 12-bit id mask) let unrelated sessions overwrite each other's tap targets Kind-salted request codes and a much wider notification-id space

Verification

  • New/updated unit tests: directory-scoped recovery, dismissed-question semantics, resolved callbacks, bridge pendingRequests, runtime id propagation (ChatViewModelQuestionTest, ClaudePermissionBridgeTest, RuntimeActivityRepositoryTest).
  • ./gradlew :app:testDebugUnitTest — 759 tests, 0 failures.
  • ./gradlew detekt spotlessCheck :app:lintDebug :app:assembleDebug — green.

Pre-PR review

判定: APPROVE

ブロッカー

  • なし

提案(非ブロッキング)

  • なし

チェック済み項目

  • 質問の復旧・重複排除・回答/却下処理
  • Claudeブリッジとフックのタイムアウト
  • 通知のランタイム付きディープリンク
  • Compose状態とランタイム切替
  • 通知コールバックの型変更
  • 関連テストとi18nリソース
  • git diff --check

…ifications

Questions asked while the user was in another chat, or long before they
opened the session, never rendered:

- Recovery fetched `GET /question?directory=<selected workspace>`, but the
  selected workspace is rarely the session's own directory, so the query
  found nothing. Resolve the directory from the session being opened and
  retry transient failures.
- Claude Code had no recovery path at all: `pendingQuestions` returned an
  empty list and the one-shot bridge event was the only delivery. Read the
  pending files back (only for sessions whose process is alive) and answer
  them, and make the decline button work via the bridge's deny response.
- The Claude hook expired questions after ~75 s: the loop counted
  iterations of a quarter-second sleep as seconds. Measure real time, keep
  permissions at five minutes, and let questions wait up to the hook
  timeout (raised to an hour).
- Dismissed cards came back on every refetch; remember the dismissal for
  the current visit to the session.

Notification taps opened the wrong chat or nothing:

- The deep link was a bare session id that was never consumed, so a second
  tap for the same session changed nothing. Deliver a one-shot tokenised
  link and consume the intent extras.
- Question/complete/error notifications carried no runtime id, so a chat on
  another agent opened against the wrong backend. Include the runtime id in
  every notification and switch to it before opening (falling back to the
  session catalog for notifications posted before this change).
- On a cold start with no runtime selected yet, `openSession` no-opped and
  the request was dropped; keep it pending until a runtime exists.
- Answered questions now cancel their notification, and the wider
  notification-id/requestCode space stops unrelated sessions colliding.
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

🔍 OpenCodeReview found 5 issue(s) in this PR.

  • ✅ Successfully posted inline: 5 comment(s)

Comment on lines +196 to +198
onSessionIdle = { sessionId, title, runtimeId ->
notifications.notifySessionComplete(sessionId, title, runtimeId)
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[maintainability · low]
このラムダはパラメータをそのまま渡すだけのラッパーで、もともとのメソッド参照のままでもシグネチャが完全一致しているためそのまま書けます(onSessionIdle = notifications::notifySessionComplete 等)。ラムダ化しても機能差はなく冗長なので、可読性・簡潔性の観点からメソッド参照を維持する方が望ましいです。

Suggestion:

Suggested change
onSessionIdle = { sessionId, title, runtimeId ->
notifications.notifySessionComplete(sessionId, title, runtimeId)
},
onSessionIdle = notifications::notifySessionComplete,

Comment on lines +146 to +147
intent.removeExtra(RuntimeNotificationHelper.EXTRA_TARGET_SESSION_ID)
intent.removeExtra(RuntimeNotificationHelper.EXTRA_RUNTIME_ID)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · low]
removeExtra による「extras の消費」は、同一プロセス内の設定変更(recreation)時に同じ Intent インスタンスが再配送されるケースには有効ですが、プロセス死亡後の復元では、システム(ActivityRecord)が保持する元インテントのコピーが再配送されるため removeExtra の変更が反映されず、最後の deep link が再発火して「すでに離れたチャットへ引き戻される」問題が残ります。コメントの担保範囲を「設定変更のみ」に限定するか、onSaveInstanceState に消費済みセッションIDを保存して onCreate で再発火を抑止するガードを追加することを検討してください。

fun pendingQuestions(): List<QuestionRequest> =
permissionBridge
.pendingRequests()
.filter { it.kind == ClaudePermissionBridge.Kind.QUESTION }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[maintainability · low]
この filter は冗長です。toQuestionRequestrequest.kind != Kind.QUESTION の場合に null を返すため(ClaudePermissionBridge.kt の実装)、後続の mapNotNull が既に非QUESTIONを除外します。フィルタを外して1パスにまとめるか、意図を明示するコメントを残すと読みやすくなります。

.pendingRequests()
.filter { it.kind == ClaudePermissionBridge.Kind.QUESTION }
.filter { sessions[it.androidSessionId]?.process?.isAlive == true }
.mapNotNull(permissionBridge::toQuestionRequest)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
mapNotNull(permissionBridge::toQuestionRequest) はパースできない質問ファイル(questions 配列が欠落・壊れている、旧フォーマット等)を静かに破棄します。イベント経路の pollPending() ではこのケースを PermissionAsked にフォールバックして拒否UIを提供していますが、リカバリ経路にはフォールバックがありません。その結果、リカバリ対象であるはずのブロックされたターンが、ホックのタイムアウト(約1時間)までカードも出ずに停滞し、ユーザーは回答も拒否もできません。pollPending() と同様にフォールバック(例: PermissionAsked として返す)を用意するか、無効なファイルを削除/期限切れ扱いにすることを検討してください。

* that, denying the turn while the card was still on its way. The hook script's own timeout
* stays just under this so it gets to deny gracefully first.
*/
const val HOOK_TIMEOUT_SEC = 3600

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
Claude Code のフック設定の timeout にはドキュメント上の上限(既定 60 秒、最大 600 秒)が設けられているため、3600 を設定しても実効的にはクランプされたり、無効とみなされる可能性があります。その場合、1時間待機という意図は実現せず、スクリプト側の質問タイムアウト(3540 秒)が有効な上限より大きく勝ってしまうため、元の「ターンが早期に拒否される」問題が解消されません。対象の Claude Code バージョンで timeout: 3600 が実際に有効に動作することを確認し、上限に合わせた値に調整することを推奨します。

@yuga-hashimoto
yuga-hashimoto merged commit 2288ac0 into main Aug 10, 2026
6 of 7 checks passed
@yuga-hashimoto
yuga-hashimoto deleted the fix/question-render-and-notification branch August 10, 2026 01:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant